2.1 基础类型与内置类型详解

本节我们将对数据类型做一个整理,同时我们会针对基础数据类型的内存分配进行讲解。

本节代码存放目录为 lesson1

基础数据类型

基础数据类型也就是在 Go 语言中是由编译器直接支持的,具有确定的内存表示方式及操作符。

基础类型的值通常是不可分割的(即原子的),并且它们直接与计算机硬件的基本操作相关。

下表所示为 Go 语言中的基础数据类型统计:

数据类型 位数 表示范围
int8 8位 -128 至 127
uint8 8位 0 至 255
int16 16位 -32,768 至 32,767
uint16 16位 0 至 65,535
int32 32位 -2,147,483,648 至 2,147,483,647
uint32 32位 0 至 4,294,967,295
int64 64位 -9,223,372,036,854,775,808 至 9,223,372,036,854,775,807
uint64 64位 0 至 18,446,744,073,709,551,615
float32 32位 1.4e-45 至 3.4e+38(IEEE-754 标准,精度约为7位小数)
float64 64位 5e-324 至 1.7e+308(IEEE-754 标准,精度约为15位小数)
complex64 64位 实部和虚部各为float32,占用32位
complex128 128位 实部和虚部各为float64,占用64位
bool 1位(逻辑上) truefalse(通常占用1字节)
string 变长 每个字符占用1-4字节,具体取决于UTF-8编码
byte 8位 等同于uint8,表示0至255
rune 32位 等同于int32,通常用于表示Unicode码点

内置类型

内置类型指的是 Go 语言标准库中直接提供的类型,包括所有基础类型以及一些更复杂的类型和数据结构。

这些类型在语言中是 内置 的,意味着它们无需用户自己定义,编译器和运行时环境直接支持它们。

基础类型是内置类型的一个子集,所有的基础类型都是内置类型,但内置类型还包括其他由基础类型或复合结构构成的复杂类型。

下表所示为 Go 语言除基本数据类型以外的内置类型:

数据类型 描述
array 固定长度的序列,所有元素类型相同,例如 [5]int 表示一个包含5个整数的数组。
slice 动态长度的序列,是对数组的一个视图,支持动态扩展,例如 []int
map 键值对(key-value)数据结构,提供哈希表的功能,例如 map[string]int
struct 复合数据类型,由不同类型的字段组成,例如 struct { Name string; Age int }
pointer 存储另一个变量的内存地址,例如 *int 表示指向整数的指针。
function 函数类型,定义函数的输入和输出,例如 func(int) string
channel 用于并发编程的通信机制,支持线程间的安全通信,例如 chan int
interface 定义一组方法的集合,类型实现了这些方法即实现了该接口,例如 interface { Read(p []byte) (n int, err error) }
uintptr 用于表示指针的无符号整数类型,适用于低级编程和与操作系统交互。

底层内存表示

首先我们需要知道,数据类型我们是为了变量、常量定义使用的,那么最终变量、常量就会在内存体现。

也就是说我们在进行开发时,最终所有的定义都会在内存中有所体现,我们现在了解这一点,方便对我们接下来的讲解进行理解。

位数是什么?

在我们基础数据类型表中,我们有一列叫做位数,那么位数到底是什么呢?

在上面我们提到了:所有定义最终都会在内存中体现

在计算机系统内存中,所有的数据都是按照二进制进行存储的,也就是说:当我们定义了变量以后,变量的值会以二进制的形式存储在内存中

我们可以猜想,所谓的位数是不是就是二进制的位数?答案是确定的,位数就是内存中实际二进制的位数。

所以,当我们定义一个int8变量时,在内存中分配了8位(bit)的空间来存储这个变量的值。

同样,int16类型的变量会占用16位int32类型的变量则占用32位,以此类推。

计算机系统内存是由无数个位(bit)所组成的,所以总结起来就是:

  • 定义的变量在实际存储到内存时,是会转换为二进制存储的

  • 各种数据类型占用了不同数量的bit,这些在编程语言层面已经被定义好。

  • 当我们声明和使用某种数据类型时,对应数量的bit会在内存中被分配和占用。

我们在使用时,可以通过下面的代码输出变量的位数:

var (
    a int8
    b int16
    c float64
)

fmt.Printf("int8  占用 %d 字节 (%d 位)\n", unsafe.Sizeof(a), unsafe.Sizeof(a)*8)
fmt.Printf("int32 占用 %d 字节 (%d 位)\n", unsafe.Sizeof(b), unsafe.Sizeof(b)*8)
fmt.Printf("float64 占用 %d 字节 (%d 位)\n", unsafe.Sizeof(c), unsafe.Sizeof(c)*8)

结果输出如下所示:

int8  占用 1 字节 (8 位)
int32 占用 2 字节 (16 位)
float64 占用 8 字节 (64 位)

表示范围是什么?

在上面我们讲到,最终数据都是以二进制的形式存储的。

那么也就是说:int8占用8位,表示在内存中分配了8位的位宽。

由于最终是以二进制存储,所以当我们定义int8时,分配的内存就是这样的:00000000,也就是初始化了8位的内存,用于后续存放我们的int8变量。

举例:var d int8 = 10;,这时候分配的内存是:00000000,十进制的数字10转换为二进制是:1010,那么这时候转换后实际才占用了4位,所以会进行补位,最终存储到内存就是这样:00001010

那么如果我们定义的是int16呢?那么存储的就是这样:00000000 00001010

在上面的表格中,我们看到int8的范围是:-128 至少 127,我们将127转换为二进制,得到:1111111,一共7位。

那么本身int8存储的是8位,为什么只可以表示7位呢?这是由于最前面还有一位是用于表示正负的。

所以如果我们是-10的话,得到的二进制将是这样:11110110。(具体的转换方法不做阐述)

在上面的转换中,第一位1表示这是一个负数,如果是0,就表示这是一个正数。

由此我们可以看到uint8所表示的范围是比int8要更大的,这就是由于uint8不需要正负数表示,所以是多出一位的。

我们可以通过下面的代码执行查看结果:

var d int8 = 10
printBinaryInt8(d)

var e int8 = -10
printBinaryInt8(e)

func printBinaryInt8(data int8) {
    size := unsafe.Sizeof(data)
    ptr := unsafe.Pointer(&data)

    for i := uintptr(0); i < size; i++ {
        byteValue := *(*byte)(unsafe.Pointer(uintptr(ptr) + i))
        fmt.Printf("%08b ", byteValue)
    }
    fmt.Println()
}

上面的代码中,就是直接输出了底层的内存表示,结果输出如下所示:

00001010 
11110110

是谁将变量值转换为二进制存储的?

在探讨这个问题之前,我们需要先了解一个知识,那就是机器指令。

我们在使用编程语言时,事实上使用的都是抽象好的高级语言,也就是说编程语言已经为我们封装好了各种与硬件和操作系统交互的操作。

比如:当我们定义一个变量时,在编译的时候,编译器就会将我们的代码编译为一系列的机器指令。

下面我会以一个伪代码的形式进行说明:

var a int8 = 1;

编译器工作时,就会将这一串代码翻译为机器指令,也就是CPU能够直接执行的指令集。

比如这样:

1. 开辟一块8位的内存空间。
2. 将十进制的1转换为二进制。
3. 将转换后的二进制值存储在开辟的内存空间中。

上面我们演示了一个过程,也就是说我们写好代码以后,在编译的时候编译器将我们的代码翻译为了一系列的机器指令。

这些指令在程序执行时由CPU逐条执行,从而完成对变量的内存分配、数据转换和存储等操作。

这也是为什么某些编程语言的程序在特定操作系统或硬件平台上无法运行的原因。

因为编译器生成的机器指令可能与目标硬件架构不兼容,或者生成的可执行文件格式不被该操作系统支持。

我们可以通过如下命令来查看生成的机器指令:

go tool compile -S lesson1.go

结果输入如下所示:

main.main STEXT size=368 args=0x0 locals=0xa8 funcid=0x0 align=0x0
        0x0000 00000 (lesson1.go:8)     TEXT    main.main(SB), ABIInternal, $176-0
        0x0000 00000 (lesson1.go:8)     MOVD    16(g), R16
        0x0004 00004 (lesson1.go:8)     PCDATA  $0, $-2
        0x0004 00004 (lesson1.go:8)     SUB     $48, RSP, R17
        0x0008 00008 (lesson1.go:8)     CMP     R16, R17
        0x000c 00012 (lesson1.go:8)     BLS     344
        0x0010 00016 (lesson1.go:8)     PCDATA  $0, $-1
        0x0010 00016 (lesson1.go:8)     MOVD.W  R30, -176(RSP)
        0x0014 00020 (lesson1.go:8)     MOVD    R29, -8(RSP)
        0x0018 00024 (lesson1.go:8)     SUB     $8, RSP, R29
        0x001c 00028 (lesson1.go:8)     FUNCDATA        ZR, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        0x001c 00028 (lesson1.go:8)     FUNCDATA        $1, gclocals·E5oFiNjeiS1rQQ8jVbtNeA==(SB)
        0x001c 00028 (lesson1.go:8)     FUNCDATA        $2, main.main.stkobj(SB)
        0x001c 00028 (lesson1.go:15)    STP     (ZR, ZR), main..autotmp_26-32(SP)
        0x0020 00032 (lesson1.go:15)    STP     (ZR, ZR), main..autotmp_26-16(SP)
        0x0024 00036 (lesson1.go:15)    MOVD    $type.uintptr(SB), R7
        0x002c 00044 (lesson1.go:15)    MOVD    R7, main..autotmp_26-32(SP)
        0x0030 00048 (lesson1.go:15)    MOVD    $main..stmp_0(SB), R8

这里面其实就是一些汇编内容,我们可以理解为这就是编译后,进一步与CPU进行交互的东西。

聊聊string

我们经常会提到string字符串是不可变的,那么到底为什么会导致它不可变呢?今天我们来一起探讨一下。

要了解string,首先我们需要知道它的底层表示是什么样的。与一般的整型不一样,string在底层是一个复合的结构,如下所示:

type stringStruct struct {
    ptr *byte  // 指向底层字节数组的指针
    len int    // 字符串的长度
}

从上面的结构我们可以看出,string的底层我们可以理解为就是一个结构体。

在结构体中,有一个指向底层字节数组的指针、一个表示字符串长度的int型变量。

那么也就是说,string结构本身在底层的布局及大小其实是固定的。

32位系统上,ptrlen占用都是32位,也就是4字节。

64位系统上,ptrlen占用都是64,也就是8字节。

同理我们可以得出,指针在32也就是与int是一样的,在不同位数的机器上是变长的。

由于考虑到安全性,所以Go语言设计团队本身就将string设计为了不可变的,也就是我们无法对string进行直接的操作进行改变。

所以结论是:字符串底层的字节数组在创建后是只读的,这意味着在Go语言中,我们无法通过直接操作string来改变其内容。

正是因为这个设计选择,使得字符串可以在多个地方安全且高效地共享。

那么我们应该如何修改字符串呢?一般我们会使用两种方法。如下代码所示:

var f string = "123456"
fmt.Printf("原字符串: %s\n", f)

fb := []byte(f)
fb[5] = '7'
fmt.Printf("修改后字符串: %s\n", string(fb))

fr := []rune(f)
fr[5] = '7'
fmt.Printf("修改后字符串: %s\n", string(fr))

var g string = "字符串探讨"
fmt.Printf("原字符串: %s\n", g)

gb := []byte(g)
gb[4] = '7'
fmt.Printf("修改后字符串: %s\n", string(gb))

gr := []rune(g)
gr[4] = '7'
fmt.Printf("修改后字符串: %s\n", string(gr))

执行结果输出如下所示:

原字符串: 123456
修改后字符串: 123457
修改后字符串: 123457

原字符串: 字符串探讨
修改后字符串: 字�7�串探讨
修改后字符串: 字符串探7

在上面的代码中,我们使用了两种方式。一种是通过[]byte将字符串转换为字节数组,之后操作修改字节数据的值;另一种是通过[]rune将字符串转换为32位数组,之后操作修改数组的值。

通过结果输出我们可以看到,当字符串不是中文时,两种方法都是正确的;当字符串是中文时,那么使用[]byte输出就不正确了。

这是由两种方法所决定的,byte等同于uint8,存储范围是0255,当我们将string转换为[]byte时,这时候的每一个元素其实是ASCII值,当我们修改的时候也是修改某一位的码值。

那么当字符串不是中文的时候,当然是没问题的,因为英文数字的码值也就只占用了一位。

但是当出现中文的时候,字符的码值就不仅仅是一位了,这时候再使用byte的方式去修改,相当于并没有修改到完整的字符,这时候就会输出乱码。

当我们使用[]rune时,每个元素代表一个完整的Unicode代码点(即一个字符),无论该字符在UTF-8中占用多少字节。

因此,[]rune在处理多字节字符(如中文)时更为准确,而[]byte在处理这类字符时容易导致数据损坏或乱码。

这一点我们修改一下代码就可以看出来:

fmt.Printf("修改后字符串: %s, 长度-> %d\n", string(gb), len(gb))

fmt.Printf("修改后字符串: %s, 长度-> %d\n", string(gr), len(gr))

执行代码输出如下所示:

修改后字符串: 字�7�串探讨, 长度-> 15
修改后字符串: 字符串探7, 长度-> 5

从输出我们可以看出,通过[]byte的方式得到的长度是15,而通过[]rune的方式我们得到的长度是5

所以我们一般情况下都是使用[]rune来进行操作。

关于string不可变我们一般会有一个误区,就是认为是可以修改的。其实不是的,当我们使用[]rune等方式进行修改时,其实是新创建了一个string字符串变量,原本的字符串是并没有被改变的。

小结

在本节中,我们探讨了变量的底层内存表示、机器指令以及string的内容,以下是本节的要点总结:

  • 变量声明后,其值在底层是以二进制形式存储的

  • 变量的位数指的是该变量在内存中所占用的二进制位数

  • 变量的表示范围由其二进制位数决定

  • Go语言编译器将代码编译为一系列机器指令,CPU根据这些指令执行操作

  • 虽然string的底层表示是一个可变的字节数组,但为了确保共享和并发安全,设计团队将string定义为不可变类型

  • 使用[]byte转换和修改字符串可能会引发问题,特别是在处理中文字符时容易导致乱码

  • 使用[]rune转换和修改字符串更加准确,但它比[]byte占用更多的内存

  • 当我们通过[]rune等方式修改字符串时,实际上是创建了一个新的字符串对象,原来的字符串并未被改变

results matching ""

    No results matching ""